// Copyright (C) 2023 即时通讯网(52im.net) & Jack Jiang.
// The RainbowChat Project. All rights reserved.
// 
// 【本产品为著作权产品，合法授权后请放心使用，禁止外传！】
// 【本次授权给：<北京小羊驼科技有限公司>，授权编号：<NT231212144350>，代码指纹：<A.702363430.550>，技术对接人微信：<ID: wxid_wbyootvkdcgj22>】
// 【授权寄送：<收件：苏先生、地址：北京市大兴区北京密码西区6号楼1单元301、电话：18613885610、邮箱：bd@huiyuanxiang-inc.com>】
// 
// 【本系列产品在国家版权局的著作权登记信息如下】：
// 1）国家版权局登记名(简称)和权证号：RainbowChat    （证书号：软著登字第1220494号、登记号：2016SR041877）
// 2）国家版权局登记名(简称)和权证号：RainbowChat-Web（证书号：软著登字第3743440号、登记号：2019SR0322683）
// 3）国家版权局登记名(简称)和权证号：RainbowAV      （证书号：软著登字第2262004号、登记号：2017SR676720）
// 4）国家版权局登记名(简称)和权证号：MobileIMSDK-Web（证书号：软著登字第2262073号、登记号：2017SR676789）
// 5）国家版权局登记名(简称)和权证号：MobileIMSDK    （证书号：软著登字第1220581号、登记号：2016SR041964）
// * 著作权所有人：江顺/苏州网际时代信息科技有限公司
// 
// 【违法或违规使用投诉和举报方式】：
// 联系邮件：jack.jiang@52im.net
// 联系微信：hellojackjiang
// 联系QQ号：413980957
// 授权说明：http://www.52im.net/thread-1115-1-1.html
// 官方社区：http://www.52im.net
package com.x52im.rainbowchat.http.file.u;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;

import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.eva.framework.httpfile.FileUpload;
import com.eva.framework.utils.EndsExceptionFactory;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.io.FileUtils;

import com.eva.epc.common.util.CommonUtils;
import com.eva.framework.dbpool.DBDepend;
import com.eva.framework.utils.LoggerFactory;
import com.x52im.rainbowchat.http.logic.LogicProcessor2;

/** 
* 服务端大文件上传处理实现根类（支持大文件断点分块上传）。
* 
* <pre>
* 【大文件断点上传的技术难点】：
* 因为标谁的http协议中并未包含文件的断点上传，这也就意味着各主流http库（比如android端的okhttp、
* 服务端的apache fileupload库等）都不能原生支持断点上传（即指定字节索引位置的文件数据上传），非
* 得让它们支持那就得直接改它们的源码了，这样无论是日后的升级、维护还是更换方案，代码都太大了。
* 
* 【本类的大文件断点上传实现思路】：
* 1）客户端将文件分成块，按块逐块上传到服务端；
* 2）服务端先将各块临时保存；
* 3）服务端判定所有块都上传完成后，将这些块临时文件拼合成正式的文件（并删除临时文件）。
* 
* 【本类的大文件断点上传实现特色】：
* 1）技术原理简单易行：不需要改任何http标准通用类代码，直接就用；
* 2）实际应用稳定可靠：在网络不好的情况下，如果需要断点上传，只需要从上次上传完成的最后一块的前推一块上传（前推
*    一块上传的目的是怕最后一块的数据因上次任务中断而不完整），技术上很经济。
* </pre>
* 
* @author hst, Jack Jiang
* @since 4.4
*/
public abstract class BigUploaderRoot extends HttpServlet
{
	private static final long serialVersionUID = 1L;
	
	/**
	 * <p>
	 * Apache文件上传组件在解析上传数据中的每个字段内容时，需要临时保存解析出的数据，以
	 * 便在后面进行数据的进一步处理（保存在磁盘特定位置或插入数据库）。因为Java虚拟机默
	 * 认可以使用的内存空间是有限的，超出限制时将会抛出“java.lang.OutOfMemoryError”错
	 * 误。如果上传的文件很大，例如800M的文件，在内存中将无法临时保存该文件内容，Apache
	 * 文件上传组件转而采用临时文件来保存这些数据；但如果上传的文件很小，例如600个字节的
	 * 文件，显然将其直接保存在内存中性能会更加好些。
	 * 
	 * <p>
	 * setSizeThreshold方法用于设置是否将上传文件已临时文件的形式保存在磁盘的临界值（以
	 * 字节为单位的int值），如果从没有调用该方法设置此临界值，将会采用系统默认值10KB。对
	 * 应的getSizeThreshold() 方法用来获取此临界值。*/
	private static final int THRESHOLD_SIZE = 1024 * 200;      // 200KB 
	private static final int MAX_FILE_SIZE = 1024 * 1024 * 200;// 200MB
	private static final int MAX_REQUEST_SIZE = MAX_FILE_SIZE;
	
	/** Apache fileupload组件要使用的文件上传临时目录 */
	private String repositoryDir;
	/** 上传成功后，文件真正保存目录（末尾不带“/”） */
	private String uploadDir;
	
	protected void doGet(HttpServletRequest request, HttpServletResponse response) 
			throws ServletException, IOException
	{
		doPost(request, response);
	}
	
	@Override
	public void init(ServletConfig config) throws ServletException 
	{
		repositoryDir = FileUtils.getTempDirectoryPath();
		uploadDir = getFileSaveDir();
		
		LoggerFactory.getLog().debug("【"+getTAG()+"上传】临时目录：" + repositoryDir);
		LoggerFactory.getLog().debug("【"+getTAG()+"上传】文件目录：" + uploadDir);
		
		// 如果正式保存目录不存在则创建之
		File up = new File(uploadDir);
		if (!up.exists())
			up.mkdirs();
	}
	
	public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
	{
		LoggerFactory.getLog().debug("[HTTP"+getTAG()+"上传] 【分块上传开始】正在处理"+getTAG()+"上传请求『支持大文件断点续传』。。。。。。");
		
		BufferedOutputStream outputStream = null;

		// 客户端上传的当前文件分块编号（起始编号是1）
		Integer schunk = null;
		// 文件总分块数
		Integer schunks = null;
		// 客户端传上来的文件名（文件的真正名字）
		String name = null;
		// 总文件的大小（而不是本次上传的“块”）
		String totalLength = null;
		// 总文件的MD5码（而不是本次上传的“块”）
		String totalFileMd5 = null;
		// 客户传上来的身份认证token，暂时为保留字段，留作日后作上传安全策略使用
		String token = null;
		// 上传者的uid
		String user_uid = null;

		try
		{
			response.setCharacterEncoding("UTF-8");

			// 出于安全考虑，检查每一个文件上传请求的token合法性（token在用户登陆时由服务端生成并返回给客户端）
			FileUpload.verifyToken(request, getTAG());

			if(uploadDir == null)
				throw new IllegalArgumentException("[HTTP"+getTAG()+"上传] uploadDir == null!");

			// checks if the request actually contains upload file
			if (!ServletFileUpload.isMultipartContent(request))
			{
				LoggerFactory.getLog().error("[HTTP"+getTAG()+"上传] 没有需要上传的文件"
						+ "，ServletFileUpload.isMultipartContent(request)!=true!");
				throw new IllegalArgumentException("[HTTP"+getTAG()+"上传]\tNot file to upload!");
			}

			// configures upload settings
			DiskFileItemFactory factory = new DiskFileItemFactory();
			factory.setSizeThreshold(THRESHOLD_SIZE);
			factory.setRepository(new File(repositoryDir));

			ServletFileUpload upload = new ServletFileUpload(factory);
			upload.setSizeMax(MAX_FILE_SIZE);
		
			List<FileItem> items = upload.parseRequest(request);

			// 服务端真正保存的文件名
			String totalFileNameSavedOnServer = null;
			// 分块文件的临时保存位置
			File chunckSavedDir = null;

			for (FileItem item : items) 
			{
				// 如果是文件类型
				if (!item.isFormField()) 
				{
					String logStr = "[其它参数：schunk="+schunk+", schunks="+schunks
							+",name="+name+", totalLength="+totalLength+", totalFileMd5="+totalFileMd5
							+", token="+token+", user_uid="+user_uid+"]";
					LoggerFactory.getLog().debug("[HTTP"+getTAG()+"上传] 正在处理文件\"块\"数据，item.getName="
							+item.getName()+logStr);

					if(schunk == null
							|| schunks ==  null
							|| CommonUtils.isStringEmpty(name, true)
							|| CommonUtils.isStringEmpty(totalLength, true)
							|| CommonUtils.isStringEmpty(totalFileMd5, true))
					{
						throw new IllegalArgumentException("[HTTP"+getTAG()+"上传] 无效的form data："+logStr+"!");
					}

					// 确保使用的md5码都用小写字母
					totalFileMd5 = totalFileMd5.toLowerCase();
					// 要保存到碰盘上的真正文件名（目前是md5码作为文件名，防止上传重名的情况）
					totalFileNameSavedOnServer = totalFileMd5;//name;

					// 分块文件的保存目录，为了防止重复重复和混乱，单独保存在“md5码_uid”的目录下
//					chunckSavedDir = new File(uploadDir+totalFileMd5+(user_uid != null?"_"+user_uid:"")+"/");
					chunckSavedDir = new File(getChunckFileDir(totalFileMd5, user_uid));
					
					if(!chunckSavedDir.exists())
					{
						chunckSavedDir.mkdirs();
						LoggerFactory.getLog().info("[HTTP"+getTAG()+"上传-第"+schunk+"/"+schunks+"块] "
								+name+"的各分\"块\"保存目录:"+chunckSavedDir.getAbsolutePath()+"创建成功！" );
					}

					if (totalFileNameSavedOnServer != null)
					{
						String chunkFileName = schunk + "_" + totalFileNameSavedOnServer;

						// 保存该“块”到文件中
						File chunkSavedFile = new File(chunckSavedDir, chunkFileName);
						item.write(chunkSavedFile);

						LoggerFactory.getLog().info("[HTTP"+getTAG()+"上传-第"+schunk+"/"+schunks+"块] "+name+"的\"块\":"
								+chunkSavedFile.getAbsolutePath()+"写入磁盘成功完成！" );
					}
				} 
				// 普通的属性字段
				else
				{
					if (item.getFieldName().equals("chunk")) 
						schunk = Integer.parseInt(item.getString());
					else if (item.getFieldName().equals("chunks"))
						schunks = Integer.parseInt(item.getString());
					else if (item.getFieldName().equals("name"))
					{
//						name = new String(item.getString());
						
						// - 说明：加入"utf-8"参数后，可解决ios端AFN网络库上传大文件时，中文文件名到服务端后乱码的问题。
						// - 补充：此参数加不加对android端的okhttp大文件上传无影响。ios端的这个文件名乱码问题，通过upload.setHeaderEncoding("UTF-8");和
						//         request.setCharacterEncoding("UTF-8"); 均无法解决。ios端AFN网络库的一些属性设置也无法解决。
						name = new String(item.getString("utf-8"));
					}
					else if (item.getFieldName().equals("totalLength"))
						totalLength = new String(item.getString());
					else if (item.getFieldName().equals("totalFileMd5"))
					{
						totalFileMd5 = new String(item.getString());
						// 保证使用的md5码是小写的
						if(totalFileMd5 != null)
							totalFileMd5 = totalFileMd5.toLowerCase();
					}
					else if (item.getFieldName().equals("token"))
						token = new String(item.getString());
					else if (item.getFieldName().equals("user_uid"))
						user_uid = new String(item.getString());
				}
			}

			// 当前传完的“块”是最后一块：表示整个文件分块上传完成了，开始合并成最终文件
			if (schunk != null && schunk.intValue() == schunks.intValue()) 
			{
				LoggerFactory.getLog().debug("[HTTP"+getTAG()+"上传-第"+schunk+"/"+schunks+"块] "+name+"的最后一块上传完成，马上合并为最终文件："
						+uploadDir+totalFileNameSavedOnServer+" >>>>>>>>");

				File totalFileNameSavedOnServerFile = new File(uploadDir, totalFileNameSavedOnServer);
				outputStream = new BufferedOutputStream(new FileOutputStream(totalFileNameSavedOnServerFile));
				// 遍历文件合并
				for (int i = 1; i <= schunks; i++) 
				{
					//System.out.println("文件合并:" + i + "/" + schunks);
					File tempFile = new File(chunckSavedDir, i + "_" + totalFileNameSavedOnServer);
					byte[] bytes = FileUtils.readFileToByteArray(tempFile);
					outputStream.write(bytes);
					outputStream.flush();
					tempFile.delete();

					LoggerFactory.getLog().info("[HTTP"+getTAG()+"上传-第"+i+"/"+schunks+"块] "+name+"的最终保存文件："
							+uploadDir+totalFileNameSavedOnServer+"已合并完成第"+i+"块...");
				}
				outputStream.flush();

				LoggerFactory.getLog().info("[HTTP"+getTAG()+"上传]【全部分块上传结束(成功)】"+name+"的最终保存文件："
						+uploadDir+totalFileNameSavedOnServer+"。");
				
				// 将最终合并完成的文件信息存库，以备使用文件时能快速高效地进行检索
				this.afterTotalFileUploadComplete(name, totalFileNameSavedOnServerFile.length()
						, totalFileMd5.toLowerCase(), user_uid);

				// 以下代码对于成功保存完最终有文件来说，并不重要，如果有异常发生则让它静默处理就好了
				try
				{
					// 最后删除分块文件存放的临时空目录
					if(chunckSavedDir.exists())
					{
						String chunckSavedDirStr = chunckSavedDir.getAbsolutePath();
						chunckSavedDir.delete();
						LoggerFactory.getLog().info("[HTTP"+getTAG()+"上传] "+name+"的分块文件临时目录："+chunckSavedDirStr+"也已成功删除完成.");
					}
				}
				catch (Exception e)
				{
					LoggerFactory.getLog().error(e.getMessage(), e);
				}
			}

//			response.getWriter().write("");
		}
		catch (EndsExceptionFactory.EInvalidTokenException ee)
		{
			LoggerFactory.getLog().error("[HTTP"+getTAG()+"上传] token验证失败了，本次上传被拒绝！", ee);
			response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
			response.getWriter().write(ee.getMessage());
		}
		catch (Exception e) 
		{
			String logStr = "[其它参数：schunk="+schunk+", schunks="+schunks
					+"newFileName="+name+", totalLength="+totalLength+", totalFileMd5="+totalFileMd5
					+", token="+token+", user_uid="+user_uid+"]";
			LoggerFactory.getLog().error("[HTTP"+getTAG()+"上传] 【全部分块上传结束(失败)】出错了，客户端上传的各参数："+logStr, e);
			response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
			response.getWriter().write(logStr);
		}
		finally
		{
			try
			{
				if (outputStream != null)
					outputStream.close();
			} 
			catch (Exception e) 
			{
				LoggerFactory.getLog().error(e.getMessage(), e);
			}
		}
	}
	
	/**
	 * 返回文件分块存放的临时目录。
	 * <p>
	 * 为了防止重复和混乱，分块文件是单独保存在形如“md5码_uid”的目录下。
	 * 
	 * @param fileMd5
	 * @param userUid
	 * @return
	 */
	public String getChunckFileDir(String fileMd5, String userUid)
	{
		return getFileSaveDir()+fileMd5+(userUid != null?"_"+userUid:"")+"/";
	}
	
	/**
	 * 上传的文件要保存的目录（此目录末尾要带“/”哦）。
	 * 
	 * @return
	 */
	protected abstract String getFileSaveDir();
	
	/** 支持的大文件类型  */
	protected abstract int getFileType();
	
	protected abstract String getTAG();
	
//	/**
//	 * 上传的文件要保存的目录（此目录末尾要带“/”哦）。
//	 * 
//	 * @return
//	 */
//	public static String getFileSaveDir()
//	{
//		return BaseConf.getInstance().getDIR_USER_BIGFILE_UPLOAD();
//	}
//	
//	/**
//	 * 文件最终保存绝对路径。
//	 * 
//	 * @return
//	 */
//	public static String getFileSavePath(String fileMd5)
//	{
//		return BaseConf.getInstance().getDIR_USER_BIGFILE_UPLOAD()+fileMd5;
//	}
//	
//	/**
//	 * 返回文件分块存放的临时目录。
//	 * <p>
//	 * 为了防止重复和混乱，分块文件是单独保存在形如“md5码_uid”的目录下。
//	 * 
//	 * @param fileMd5
//	 * @param userUid
//	 * @return
//	 */
//	public static String getChunckFileDir(String fileMd5, String userUid)
//	{
//		return getFileSaveDir()+fileMd5
//				+(userUid != null?"_"+userUid:"")+"/";
//	}
	
	/**
	 * 将最终合并完成的文件信息存库，以备使用文件时能快速高效地进行检索。
	 * 
	 * @param fileName
	 * @param res_size
	 * @param fileMd5
	 * @param user_id
	 * @throws Exception
	 */
	private void afterTotalFileUploadComplete(String fileName
			, long res_size, String fileMd5, String user_id) throws Exception
	{
		String fileMd5Lowsercase = fileMd5.toLowerCase();
		// 已经存在这条记录了
		if(LogicProcessor2.db.queryData("select 1 from missu_big_files where res_md5='"
				+fileMd5Lowsercase+"'").size()>0)
		{
			LogicProcessor2.db.update("update missu_big_files set res_file_name=?"
					+ ",res_human_size=?,res_size=?,update_time="+DBDepend.getDefaultDatetimeFunc()
					+ ", update_user_id=? where res_md5=?"
					, new Object[] {fileName, CommonUtils.getConvenientFileSize(res_size, 2)
							, res_size, user_id, fileMd5Lowsercase}, false);
		}
		else
		{
			LogicProcessor2.db.update(
					"INSERT INTO missu_big_files(res_file_name,res_human_size" +
					",res_size,res_md5,create_time,create_user_uid,res_type) " +
					"VALUES(?,?,?,?, "+DBDepend.getDefaultDatetimeFunc()+",?,?)"
					, new Object[]{fileName, CommonUtils.getConvenientFileSize(res_size, 2), res_size, fileMd5, user_id, getFileType()}
					, false);
		}
	}

}
